// The waskung package generates HTML, in a programming style inspired by
// Seaside, the Smalltalk web framework. However, it is not a full web
// framework like Seaside. (Waskung is Mayangna for "shore", as an allusion to
// Seaside.)
package waskung

import (
	"bytes"
	"compress/gzip"
	"fmt"
	"net/http"
	"strconv"
	"strings"

	"golang.org/x/net/html"
)

// An HTMLWriter generates HTML and accumulates it in a buffer.
type HTMLWriter struct {
	// Errors contains errors that were encountered while generating the HTML.
	Errors []error

	// buf stores the generated HTML.
	buf bytes.Buffer

	// depth is the current indentation level.
	depth int

	// midLine is whether the output position is currently in the middle of a
	// line.
	midLine bool

	// inScript and inStyle are whether the current position is in a script or
	// style element.
	inScript, inStyle bool

	// pending holds a TagBrush that has been started but not added to the
	// buffer yet.
	pending *TagBrush

	// scripts holds the paths of scripts that have been added to the page with
	// the Scripts method.
	scripts []string

	// styles holds the paths of stylesheets that have been added to the page
	// with the Stylesheets method.
	styles []string
}

func (h *HTMLWriter) startLine() {
	if h.midLine {
		h.buf.WriteByte('\n')
		h.midLine = false
	}
	for i := 0; i < h.depth; i++ {
		h.buf.WriteByte('\t')
	}
}

// WriteToken adds an html.Token to the HTML in h's buffer. If the token
// represents a void element, it should be a SelfClosingTagToken. WriteToken
// does not correct mis-nested tags, so use it carefully.
func (h *HTMLWriter) WriteToken(tok html.Token) {
	h.clearPending()

	switch tok.Type {
	case html.DoctypeToken, html.CommentToken:
		h.startLine()
		fmt.Fprintln(&h.buf, tok)

	case html.TextToken:
		lines := strings.Split(tok.Data, "\n")
		if h.inScript || h.inStyle {
			for _, line := range lines {
				line = strings.TrimSpace(line)
				if line == "" {
					continue
				}
				tempChange := 0
				if line[0] == '.' && h.inScript {
					tempChange = 1
				}
				for i := 0; i < len(line) && (line[i] == ')' || line[i] == ']' || line[i] == '}'); i++ {
					tempChange--
				}
				h.depth += tempChange
				h.startLine()
				h.depth -= tempChange
				fmt.Fprintln(&h.buf, line)
				for _, c := range line {
					switch c {
					case '(', '[', '{':
						h.depth++
					case ')', ']', '}':
						h.depth--
					}
				}
			}
			return
		}

		for i, line := range lines {
			if i != 0 && h.midLine {
				h.buf.WriteByte('\n')
				h.midLine = false
			}
			trimmed := strings.Trim(line, "\r\n\t ")
			if trimmed != "" {
				if !h.midLine || trimmed[0] != line[0] {
					h.startLine()
				}
				fmt.Fprint(&h.buf, html.EscapeString(trimmed))
				if trimmed[len(trimmed)-1] == line[len(line)-1] {
					h.midLine = true
				} else {
					h.buf.WriteByte('\n')
					h.midLine = false
				}
			}
		}

	case html.StartTagToken:
		inline := inlineElements[tok.Data]
		if !h.midLine || !inline {
			h.startLine()
		}
		h.depth++
		fmt.Fprint(&h.buf, tok)
		if inline {
			h.midLine = true
		} else {
			h.buf.WriteByte('\n')
		}
		switch tok.Data {
		case "script":
			h.inScript = true
		case "style":
			h.inStyle = true
		}

	case html.EndTagToken:
		h.depth--
		inline := inlineElements[tok.Data]
		if !h.midLine || !inline {
			h.startLine()
		}
		fmt.Fprint(&h.buf, tok)
		if inline {
			h.midLine = true
		} else {
			h.buf.WriteByte('\n')
		}
		h.inScript = false
		h.inStyle = false

	case html.SelfClosingTagToken:
		inline := inlineElements[tok.Data]
		if !h.midLine || !inline {
			h.startLine()
		}
		tok.Type = html.StartTagToken
		fmt.Fprint(&h.buf, tok)
		if inline {
			h.midLine = true
		} else {
			h.buf.WriteByte('\n')
		}
	}
}

// openAndClose generates an opening and closing tag for tok on the same line,
// optionally including some text content between them.
func (h *HTMLWriter) openAndClose(tok html.Token, text string) {
	h.clearPending()

	inline := inlineElements[tok.Data]
	if !h.midLine || !inline {
		h.startLine()
	}
	fmt.Fprint(&h.buf, tok)

	if text != "" && tok.Data != "script" && tok.Data != "style" {
		text = html.EscapeString(text)
	}
	fmt.Fprint(&h.buf, text)

	tok.Type = html.EndTagToken
	tok.Attr = nil
	fmt.Fprint(&h.buf, tok)

	if inline {
		h.midLine = true
	} else {
		h.buf.WriteByte('\n')
	}
}

var voidElements = map[string]bool{
	"area":    true,
	"base":    true,
	"br":      true,
	"col":     true,
	"command": true,
	"embed":   true,
	"hr":      true,
	"img":     true,
	"input":   true,
	"keygen":  true,
	"link":    true,
	"meta":    true,
	"param":   true,
	"source":  true,
	"track":   true,
	"wbr":     true,
}

var inlineElements = map[string]bool{
	"b":        true,
	"big":      true,
	"br":       true,
	"i":        true,
	"small":    true,
	"tt":       true,
	"abbr":     true,
	"acronym":  true,
	"cite":     true,
	"dfn":      true,
	"em":       true,
	"kbd":      true,
	"strong":   true,
	"samp":     true,
	"var":      true,
	"a":        true,
	"bdo":      true,
	"img":      true,
	"map":      true,
	"object":   true,
	"q":        true,
	"span":     true,
	"sub":      true,
	"sup":      true,
	"button":   true,
	"input":    true,
	"label":    true,
	"select":   true,
	"textarea": true,
}

// Text appends a text node.
func (h *HTMLWriter) Text(t string) {
	h.WriteToken(html.Token{
		Type: html.TextToken,
		Data: t,
	})
}

// Doctype appends a doctype declaration.
func (h *HTMLWriter) Doctype(t string) {
	h.WriteToken(html.Token{
		Type: html.DoctypeToken,
		Data: t,
	})
}

// Comment appends an HTML comment.
func (h *HTMLWriter) Comment(t string) {
	h.WriteToken(html.Token{
		Type: html.CommentToken,
		Data: t,
	})
}

// clearPending writes the pending element, if any, to the buffer.
func (h *HTMLWriter) clearPending() {
	p := h.pending
	if p == nil {
		return
	}
	h.pending = nil

	if voidElements[p.tag] {
		h.WriteToken(html.Token{
			Type: html.SelfClosingTagToken,
			Data: p.tag,
			Attr: p.attr,
		})
		return
	}

	h.openAndClose(html.Token{
		Type: html.StartTagToken,
		Data: p.tag,
		Attr: p.attr,
	}, "")
}

// Bytes returns the HTML in the buffer.
func (h *HTMLWriter) Bytes() []byte {
	h.clearPending()
	return h.buf.Bytes()
}

// Error records an error.
func (h *HTMLWriter) Error(err error) {
	h.Errors = append(h.Errors, err)
}

// ServeHTTP sends h's HTML content as the response on w, with the proper
// Content-Type, and gzip compression when appropriate. If h.Errors is not
// empty, it sends an error page instead of the normal content.
func (h *HTMLWriter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if len(h.Errors) > 0 {
		h.buf.Reset()
		h.Html().With(func() {
			h.Head().With(func() { h.Title("Error") })
			h.Body().With(func() {
				h.Text("The following errors were encountered while rendering this page:")
				h.Ul().With(func() {
					for _, err := range h.Errors {
						h.Li().With(func() {
							for i, line := range strings.Split(err.Error(), "\n") {
								if i > 0 {
									h.Br()
								}
								h.Text(line)
							}
						})
					}
				})
			})
		})

		msg := h.Bytes()
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Header().Set("Content-Length", strconv.Itoa(len(msg)))
		w.WriteHeader(500)
		w.Write(msg)
		return
	}

	content := h.Bytes()
	if len(content) == 0 {
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && len(content) > 1000 {
		gzw := gzip.NewWriter(w)
		defer gzw.Close()
		w.Header().Set("Content-Encoding", "gzip")
		gzw.Write(content)
	} else {
		w.Header().Set("Content-Length", strconv.Itoa(len(content)))
		w.Write(content)
	}
}

// HasScript returns whether the script at src has already been referenced on
// the page.
func (h *HTMLWriter) HasScript(src string) bool {
	for _, s := range h.scripts {
		if s == src {
			return true
		}
	}
	return false
}

// HasStylesheet returns whether the stylesheet at href has already been
// referenced on the page.
func (h *HTMLWriter) HasStylesheet(href string) bool {
	for _, s := range h.styles {
		if s == href {
			return true
		}
	}
	return false
}
